# 03 — Math & Sign Conventions ## Black-Scholes (the foundation) All greeks are computed by `options_metrics.py` from first principles. Schwab's greeks (which return `-999.0` outside RTH) are ignored on principle — homegrown is more consistent and works 24/7. Standard BS with continuous dividend yield `q`: ``` d1 = (ln(S/K) + (r - q + 0.5σ²) T) / (σ√T) d2 = d1 - σ√T call = S e^(-qT) N(d1) - K e^(-rT) N(d2) put = K e^(-rT) N(-d2) - S e^(-qT) N(-d1) ``` Per-share greeks: ``` gamma = e^(-qT) φ(d1) / (S σ √T) delta_call = e^(-qT) N(d1) delta_put = e^(-qT) (N(d1) - 1) vanna = -e^(-qT) φ(d1) d2 / σ ``` φ is the standard normal pdf, N is the cdf. ## Implied volatility Per-contract IV is recovered by **Brent-bisection** inverting the BS pricing function against the bid-ask mid. Bounds 1e-4 to 5.0, 80 iters, tol 1e-6 — converges essentially always for liquid strikes. For settlement-only data (where bid/ask = 0), the settlement price is used as mark. Less precise for non-actively-traded strikes but covers the long tail. ## Sign convention (SpotGamma-style) This is the *single most important* design choice and it has been refactored once: ```python sign = -1 if c["side"] == "CALL" else +1 ``` **Why** — assume customers are net long both calls and puts (standard retail-flow assumption): - Customers long calls → dealers short calls → **negative call GEX** = resistance overhead - Customers long puts → dealers short puts (from a textbook view), BUT puts are conventionally signed positive here because the *put-driven dealer hedge* (selling stock as price falls below the strike) acts as support for price So in dashboard language: - **Call GEX (resistance) is always negative or zero** - **Put GEX (support) is always positive or zero** - **Net GEX = call_GEX + put_GEX** (typically negative since call-side is usually larger in magnitude for index futures) The regime banner ("POSITIVE GAMMA" / "NEGATIVE GAMMA") is decided by **spot vs flip**, NOT the sign of net GEX: - Spot above flip = positive-gamma regime = stabilizing - Spot below flip = negative-gamma regime = destabilizing This is the correct interpretation per SpotGamma's published methodology and matches the screenshots in `examples/`. ## Dollar GEX formula ``` GEX$_per_strike = sign × γ × OI × M × S² × 0.01 ``` Per term: - `sign`: -1 for calls, +1 for puts (above) - `γ`: BS gamma at current spot, computed per-contract - `OI`: open interest in contracts - `M`: contract multiplier — **CRITICAL** to get right per product (see below) - `S²`: spot price squared - `0.01`: scales to dollar-per-1%-move convention (1% = 0.01 in price terms) The `× spot × 0.01` chunk converts per-share gamma into "$ delta change per 1% price move." Then `× spot` again gives "$ amount the dealer's hedge needs to absorb." ## Contract multipliers Each contract represents a different number of shares/units, and the GEX formula scales linearly with this: | Product | Multiplier M | Source | |---|---|---| | Equity ETF options (SPY, QQQ, IWM, GLD) | 100 | OCC standard | | Equity index options (SPX, NDX, RUT) | 100 | OCC standard | | **ES** e-mini S&P 500 futures options | **50** | CME `unit_of_measure_qty` | | **NQ** e-mini Nasdaq-100 futures options | **20** | CME `unit_of_measure_qty` | | **RTY** e-mini Russell 2000 futures options | 50 | CME | | **GC** gold futures options (OG.OPT) | 100 | CME | DataBento's `definition` schema gives `unit_of_measure_qty` per contract — we propagate this through as `c["multiplier"]`. For Schwab REST chains (used by `dashboard.py` only), default is 100. If you forget to multiply by the right M, futures GEX comes out 2x–5x off, walls still rank correctly (same scale factor cancels), but headline dollar amounts are wrong. ## Net delta exposure ``` net_delta_$ = sign × δ × OI × M × S ``` Same dealer-sign convention. Net delta tells you how much directional stock the dealers must hold (positive = long stock = supportive on downside; negative = short stock = caps upside). ## Vanna exposure ``` vanna_$_per_1pt_IV = sign × vanna × OI × M × S × 0.01 ``` The `× 0.01` converts per-1.0-sigma vanna to per-1%-IV-point. Positive total vanna = rising IV pulls dealers to buy stock = supportive. ## Gamma flip detection ```python cum = 0 crossings = [] for row in rows_sorted_by_strike: cum += row["gex"] if prev_cum * cum < 0: # real sign change (both sides non-zero, opposite signs) # linear-interpolate the crossing point frac = -prev_cum / (cum - prev_cum) crossings.append(prev_K + frac * (row["strike"] - prev_K)) flip = min(crossings, key=lambda x: abs(x - spot)) # nearest to spot ``` The "nearest to spot" tiebreaker matters — without it, deep-OTM bookkeeping crossings (where cumulative wobbles from 0 to slightly negative because of one stray OI) would be returned. The first version of the code had exactly that bug and produced flip = 440 for SPY at spot 745. ## Max pain ```python for K in all_strikes: pain[K] = Σ call_OI × max(0, S_strike - K) × M + Σ put_OI × max(0, K - S_strike) × M max_pain_strike = argmin pain[K] ``` Computed per expiration. The headline max pain in the dashboard = the soonest-expiration value. ## Things deliberately NOT in the math - **No "intraday-OI correction."** OI by OPRA definition is settled overnight. SpotGamma / QuantVue do intraday adjustment by tracking opening-vs-closing trade direction from MBO data; we don't (yet — see [[07 - Future Work]]). - **No vol-surface modeling.** Each strike's IV is recovered independently from its own mid. We don't fit a smile, so deep wings can have noisy IVs on illiquid strikes. - **No exact-time-to-expiration.** We use `dte / 365` for T. Intraday-precision T (e.g., 0DTE with 3h 15min left) would tighten gamma estimates near 0DTE but isn't done. ## See also - [[02 - Concepts (ELI5)]] — the intuition behind these formulas - [[04 - Symbology & Gotchas]] — multipliers per CME symbol